﻿/***************************************************************************
 * 
 *  Copyright 2008 Anthony Tambrin
 * 
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 * 
 *  
 *  @page 				StickyObject.as
 *  @version			0.1
 *  @description		Mouse-following "sticky" object component class. 
 * 						Features include:
 * 						1) Follows mouse when clicked (or dragged)
 * 						2) Shake mouse to release
 * 						3) Shift-click to undo stickiness
 * 
 ***************************************************************************/

package com.ui {
	
	/********************************************************
	 * Imports
	 ********************************************************/
	import flash.display.MovieClip;
	import flash.events.MouseEvent;
	import flash.events.Event;
	import flash.display.DisplayObject;
	import flash.geom.Rectangle;
	import flash.ui.Mouse;
	import flash.display.BitmapData;
	import flash.display.Bitmap;
	import flash.geom.Matrix;
	import flash.events.KeyboardEvent;
	import flash.ui.Keyboard;
	import flash.display.Stage;
	
	public class StickyObject extends MovieClip {
		
		/********************************************************
		 * Public Constants
		 ********************************************************/
		public static const EVENT_UPDATE:String = "stickyObjectUpdateEvent";
		
		/********************************************************
		 * Private Constants
		 ********************************************************/
		private static const DEFAULT_STICKY_ALPHA:Number = 0.4;
		private static const DEFAULT_STICKY_SIZE:Number = 30; // assume object width = height
		private static const DEFAULT_STICKY_RADIUS:Number = 50;
		private static const DEFAULT_STICKY_RADIUS_INCREMENT:Number = 30;
		private static const DEFAULT_MOVEMENT_DELAY:Number = 0.4;
		private static const DEFAULT_SHAKE_SENSITIVITY:int = 20;
		private static const DEFAULT_SHAKE_COUNT:int = 3;
		
		private static const DEG_TO_RAD:Number = 0.0174532925;
		private static const RAD_TO_DEG: Number = 57.2957795132;
		
		/********************************************************
		 * Private Components
		 ********************************************************/
		private var _stage:Stage;
		private var _object:MovieClip;
		private var _thumbnail:MovieClip;
		
		/********************************************************
		 * Init Object Members
		 ********************************************************/
		private var _stickyAlpha:Number;
		private var _stickySize:Number;
		private var _stickyRadius:Number;
		private var _stickyRadiusIncrement:Number;
		
		/********************************************************
		 * Private Class Members
		 ********************************************************/
		private static var _objectCount:int = 0;
		private static var _isMouseDown:Boolean = false;
		private static var _layerCapacity:Array = new Array();
		private static var _layers:Array = new Array();
		private static var _stickyLayer:MovieClip;
		
		/********************************************************
		 * Private Members
		 ********************************************************/
		private var _mx:Number; // mouse X
		private var _my:Number; // mouse X
		private var _ma:Number; // mouse angle
		private var _mx0:Number; // previous mouse X
		private var _my0:Number; // previous mouse X
		private var _ma0:Number; // previous mouse angle
		private var _mda:Number; // angle difference
		private var _dx:Number; // delta x
		private var _dy:Number; // delta y
		private var _vx:Number; // velocity x
		private var _vy:Number; // velocity y
		
		private var _mouseShakeCount:int;
		private var _isShiftPressed:Boolean;
		private var _isFollowMouse:Boolean;
		private var _originalAlpha:Number;
		private var _originalWidth:Number;
		private var _originalHeight:Number;
		private var _stickyScale:Number;
		private var _stickyWidth:Number;
		private var _stickyHeight:Number;
		private var _stickyIndex:int;
		private var _stickyIndexInLayer:int;
		private var _xOffset:Number;
		private var _yOffset:Number;
		private var _radialLayerIndex:int;
		private var _targetX:Number;
		private var _targetY:Number;
		private var _originalX:Number; // object's original X position
		private var _originalY:Number; // object's original Y position
		private var _movementDelay:Number;
		private var _mouseMovementCount:Number;
		private var _shakeSensitivity:int; // mouse shake detection sensitivity (1:hardest ... 10:easiest)
		private var _shakeCount:int; // how many shakes until considered shake (1:easiest ... 10:hardest)
	
		/********************************************************
		 * Constructor
		 ********************************************************/
		public function StickyObject(p_object:MovieClip, p_initObject:Object=null) {
			p_initObject = p_initObject ? p_initObject : new Object();
			if (p_initObject.stickyLayer) _stickyLayer = p_initObject.stickyLayer;
			_stickyAlpha = p_initObject.stickyAlpha ? p_initObject.stickyAlpha : DEFAULT_STICKY_ALPHA;
			_stickySize = p_initObject.stickySize ? p_initObject.stickySize : DEFAULT_STICKY_SIZE;
			_stickyRadius = p_initObject.stickyRadius ? p_initObject.stickyRadius : DEFAULT_STICKY_RADIUS;
			_stickyRadiusIncrement = p_initObject.stickyRadiusIncrement ? p_initObject.stickyRadiusIncrement : DEFAULT_STICKY_RADIUS_INCREMENT;
			_movementDelay = p_initObject.movementDelay ? p_initObject.movementDelay : DEFAULT_MOVEMENT_DELAY;
			_shakeSensitivity = p_initObject.shakeSensitivity ? p_initObject.shakeSensitivity : DEFAULT_SHAKE_SENSITIVITY;
			_shakeCount = p_initObject.shakeCount ? p_initObject.shakeCount : DEFAULT_SHAKE_COUNT;
			if (p_initObject.stickyLayer) _stickyLayer = p_initObject.stickyLayer;
	  
			_object = p_object;
			_object.addEventListener(Event.ADDED_TO_STAGE, onAddedToStage);
			
			if (_object.stage) {
				mainStage = _object.stage;
			}
			
			initMembers();
			initComponents();
			initListeners();
		}
	
		/********************************************************
		 * Initialisation Methods
		 ********************************************************/
		private function initMembers():void {
			_mx0 = -1;
			_my0 = -1;
			_ma0 = -1;
			_mda = 0;
			_mouseMovementCount = 0;
			_mouseShakeCount = 0;
			_radialLayerIndex = -1;
			_stickyIndex = -1;
			_stickyIndexInLayer = -1;
			_isShiftPressed = false;
			_isFollowMouse = false;
			_originalAlpha = _object.alpha;
			_originalWidth = _object.width;
			_originalHeight = _object.height;
			_originalX = _object.x;
			_originalY = _object.y;
			_targetX = _originalX;
			_targetY = _originalY;
			
			var size:Number = _originalWidth > _originalHeight ? _originalWidth : _originalHeight;
			_stickyScale = _stickySize / size;
			_stickyWidth = _stickyScale * _originalWidth;
			_stickyHeight = _stickyScale * _originalHeight;
		}
		
		private function initComponents():void {
			// sticky layer
			if (!_stickyLayer) {
				_stickyLayer = new MovieClip();
				if (_object.parent) {
					_object.parent.addChild(_stickyLayer);
				}
			}
			
			// create clone
			_thumbnail = new MovieClip();
			var matrix:Matrix = new Matrix();
			var scale:Number = _stickyScale;
			matrix.translate(_object.width/2, _object.height/2);
			matrix.scale(scale, scale);
			var thumbnailBitmapData:BitmapData = new BitmapData(_object.width*scale, _object.height*scale, true, 0x000000);
			thumbnailBitmapData.draw(_object, matrix, null, null, null, true);
			var thumbnailBitmap:Bitmap = new Bitmap(thumbnailBitmapData, "auto", true);
			thumbnailBitmap.x = -thumbnailBitmap.width/2;
			thumbnailBitmap.y = -thumbnailBitmap.height/2;
			_thumbnail.addChild(thumbnailBitmap);
			_thumbnail.alpha = _stickyAlpha;
		}
		
		private function initListeners():void {
			_object.addEventListener(Event.ENTER_FRAME, onObjectEnterFrame);
			_object.addEventListener(MouseEvent.MOUSE_DOWN, onObjectMouseDown);
			_object.addEventListener(MouseEvent.MOUSE_OVER, onObjectMouseOver);
		}
		
		private function initStageListeners():void {
			if (!_stage) return;
		
			_stage.addEventListener(MouseEvent.MOUSE_DOWN, onStageMouseDown);
			_stage.addEventListener(MouseEvent.MOUSE_MOVE, onStageMouseMove);
			_stage.addEventListener(MouseEvent.MOUSE_UP, onStageMouseUp);
			_stage.addEventListener(KeyboardEvent.KEY_DOWN, onStageKeyDown);
			_stage.addEventListener(KeyboardEvent.KEY_UP, onStageKeyUp);
			_stage.addEventListener(EVENT_UPDATE, onObjectUpdate);
		}
		
		/********************************************************
		 * Accessors
		 ********************************************************/
		public function get stickyIndex():int {
			return _stickyIndex;
		}
		public function set stickyIndex(p_index:int):void {
			_stickyIndex = p_index;
		}	
		
		public function get stickyIndexInLayer():int {
			return _stickyIndexInLayer;
		}
		public function set stickyIndexInLayer(p_index:int):void {
			_stickyIndexInLayer = p_index;
		}
		
		public function get radialLayerIndex():int {
			return _radialLayerIndex;
		}
		public function set radialLayerIndex(p_index:int):void {
			_radialLayerIndex = p_index;
		}	
		
		public function set mainStage(p_stage:Stage):void {
			_stage = p_stage;
			initStageListeners();
		}
		
		public function set delay(p_movementDelay:Number):void {
			_movementDelay = p_movementDelay;
		}
		public function get delay():Number {
			return _movementDelay;
		}
		
		public function set shakeCount(p_shakeCount:Number):void {
			_shakeCount = p_shakeCount;
		}
		public function get shakeCount():Number {
			return _shakeCount;
		}
		
		public function set shakeSensitivity(p_shakeSensitivity:Number):void {
			_shakeSensitivity = p_shakeSensitivity;
		}
		public function get shakeSensitivity():Number {
			return _shakeSensitivity;
		}
		
		/********************************************************
		 * Public Methods
		 ********************************************************/
		
		/********************************************************
		 * Private Methods
		 ********************************************************/	
		private function enableSticky():void {
			// skip if already following mouse
			if (_isFollowMouse) return;
			
			_isFollowMouse = true;
			
			// move thumbnail to object
			_thumbnail.x = _object.x;
			_thumbnail.y = _object.y;
			
			// hide the real object
			_object.visible = false;
			
			// add the thumbnail into sticky layer
			_stickyLayer.addChild(_thumbnail);
			
			// find the next available layer
			if (_radialLayerIndex < 0) {
				_radialLayerIndex = getAvailableLayerIndex();
				// remember the layer position so other objects know 
				_stickyIndexInLayer = _layers[_radialLayerIndex].length;
				_layers[_radialLayerIndex].push(this);
			}
			
			// keep track of the sticky object count
			_stickyIndex = _objectCount;
			++_objectCount;
			if (_object.stage) {
				_object.stage.dispatchEvent(new Event(EVENT_UPDATE));
			}
		}
		
		private function disableSticky():void {
			// skip if already not following mouse
			if (!_isFollowMouse) return;
			
			_isFollowMouse = false;
			
			// move object to thumbnail
			_targetX = _originalX;
			_targetY = _originalY;
			_object.x = _thumbnail.x;
			_object.y = _thumbnail.y;
			
			// show the real object
			_object.visible = true;
			
			// remove the thumbnail into sticky layer
			_stickyLayer.removeChild(_thumbnail);
			
			// remove thumbnail from layers array
			var currentLayer:Array = _layers[_radialLayerIndex] as Array;
			var i:int = currentLayer.length;
			var obj:StickyObject;
			while (--i >= 0) {
				obj = currentLayer[i];
				// decrease sticky index for all objects after this object
				if (obj.stickyIndexInLayer > stickyIndexInLayer) {
					--obj.stickyIndexInLayer;
					--obj.stickyIndex;
				}
				// remove this object from array
				if (obj == this) {
					currentLayer.splice(i, 1);
				}
			}
			// take one object from the next layer into this layer and so on
			var j:int = _radialLayerIndex-1;
			var length:int = _layers.length-1;
			var nextLayer:Array;
			while (++j < length) {
				currentLayer = _layers[j] as Array;
				nextLayer = _layers[j+1] as Array;
				i = nextLayer.length;
				while (--i >= 0) {
					obj = nextLayer[i];
					--obj.stickyIndexInLayer;
					--obj.stickyIndex;
					// put the first object from the next layer into current layer
					if (i == 0) {
						// update the sticky index accordingly
						--obj.radialLayerIndex;
						obj.stickyIndexInLayer = currentLayer.length;
						currentLayer.push(obj);
						// remove the duplicate from next layer
						nextLayer.splice(i, 1);
					}
				}
			}
			
			// reset sticky parameters
			--_objectCount;
			_stickyIndex = -1;
			_radialLayerIndex = -1;
			if (_object.stage) {
				_object.stage.dispatchEvent(new Event(EVENT_UPDATE));
			}
		}
		
		private function updateLayerCapacity(p_layerIndex:int):void {
			var rad:Number = _stickyRadius + p_layerIndex * _stickyRadiusIncrement;
			var circ:Number = Math.PI * rad * 2; // thumbnail radial circumference
			var capacity:Number = Math.floor(circ / _stickySize);
			_layerCapacity[p_layerIndex] = capacity;
			if (!_layers[p_layerIndex]) {
				_layers[p_layerIndex] = new Array();
			}
		}
		
		private function getAvailableLayerIndex():int {
			var i:int = -1;
			var length:int = _layers.length;
			var layerIndex:int = -1;
			// find vacant layer from the currently available ones
			while (++i < length) {
				if (_layers[i].length < _layerCapacity[i]) {
					layerIndex = i;
					break;
				}
			}
			// if no vacant layer, create and return a new one
			if (layerIndex < 0) {
				layerIndex = length;
				updateLayerCapacity(layerIndex);
			}
			return layerIndex;
		}
		
		private function updatePosition():void {			
			if (!_isFollowMouse) return;
			
			_mx = mouseX;
			_my = mouseY;
			_targetX = _mx + _xOffset;
			_targetY = _my + _yOffset;
		}
		
		// TODO: optimise! This same code is currently run multiple times
		private function detectShake():void {		
			if (!_isFollowMouse || _isShiftPressed) return;
			
			_mx = mouseX;
			_my = mouseY;
			
			if (_mx0 < 0) _mx0 = _mx;
			if (_my0 < 0) _my0 = _my;
			
			_ma = findAngle(_mx0, _my0, _mx, _my);
			if (_ma0 < 0) _ma0 = _ma;
			
			_mda = findAngleDifference(_ma, _ma0);
			if (_mda < 0) _mda = -_mda;
			
			// count angle change to identify shake
			if (_mda > 90) ++_mouseShakeCount;
			else {
				++_mouseMovementCount;
				if (_mouseMovementCount > _shakeSensitivity) {
					_mouseShakeCount = 0;
					_mouseMovementCount = 0;
				}
			}
			
			// assume mouse shake if angle change counted more than a specific number
			if (_mouseShakeCount > _shakeCount) {
				_mouseShakeCount = 0;
				_mx0 = -1;
				_my0 = -1;
				_ma0 = -1;
				_mda = 0;
				disableSticky();
			} else {
				_mx0 = _mx;
				_my0 = _my;
				_ma0 = _ma;
			}
		}
	
		private function findAngle(p_x1:Number, p_y1:Number, p_x2:Number, p_y2:Number):Number {
			var x:Number, y:Number, r:Number;
			var angle:Number;
			
			x = p_x2-p_x1;
			y = p_y2-p_y1;
			r = Math.sqrt(x*x+y*y);
			
			// 1st Quadrant(SouthEast) of our Coordinate Space 
			angle = Math.round(Math.asin(y/r) * RAD_TO_DEG);
			// 2nd Quadrant(SouthWest) of our Coordinate Space 
			if (p_x2 <= p_x1 && p_y2 > p_y1) angle = 180-angle;
			// 3rd Quadrant(NorthWest) of our Coordinate Space 
			else if (p_x2 < p_x1 && p_y2 <= p_y1) angle = 180-angle;
			// 4th Quadrant(NorthEast) of our Coordinate Space 
			else if (p_x2 >= p_x1 && p_y2 < p_y1) angle += 360;
			
			// Set the angle to match Cartesian Space
			angle += 90;
			if (angle >= 360) angle %= 360;
		
			return angle;
		}

		private function findAngleDifference(p_fromAngle:Number, p_toAngle:Number):Number {
			var diff:Number = p_fromAngle - p_toAngle;
			if (diff > 180) diff -= 360;
			else if (diff < -180) diff += 360;
			return diff;
		}

		
		/********************************************************
		 * Event Handlers
		 ********************************************************/
		private function onAddedToStage(p_event:Event):void {
			if (_object.stage) mainStage = _object.stage;
		} 
		
		private function onStageMouseDown(p_event:MouseEvent):void {
			// ignore if this component is disabled
			if (!enabled) return;
			
			_isMouseDown = true;
		}
		
		private function onStageMouseUp(p_event:MouseEvent):void {
			// ignore if this component is disabled
			if (!enabled) return;
			
			_isMouseDown = false;
		}
		
		private function onStageMouseMove(p_event:MouseEvent):void {
			// ignore if this component is disabled
			if (!enabled) return;
			
			updatePosition();
			detectShake();
		}
		
		private function onObjectMouseDown(p_event:MouseEvent):void {
			// ignore if this component is disabled
			if (!enabled) return;
			
			if (_isShiftPressed) return;
			_isMouseDown = true;
			enableSticky();
		}
		
		private function onObjectEnterFrame(p_event:Event):void {
			// ignore if this component is disabled
			if (!enabled) return;
			
			// calculate movement delay
			if (_object.visible) {
				_dx = _targetX - _object.x;
				_dy = _targetY - _object.y;
			} else {
				if (!_isShiftPressed) {
					_dx = _targetX - _thumbnail.x;
					_dy = _targetY - _thumbnail.y;
				}
			}
			_vx = _dx * _movementDelay;
			_vy = _dy * _movementDelay;
			
			// move object or thumbnail to target position
			if (_object.visible) {
				_object.x += _vx;
				_object.y += _vy;
			} else {
				if (!_isShiftPressed) {
					_thumbnail.x += _vx;
					_thumbnail.y += _vy;
				}
			}
		}
		
		private function onObjectMouseOver(p_event:MouseEvent):void {
			// ignore if this component is disabled
			if (!enabled) return;
			
			if (!_isMouseDown || _isShiftPressed) return;
			enableSticky();
		}
		
		private function onThumbnailMouseDown(p_event:MouseEvent):void {
			// ignore if this component is disabled
			if (!enabled) return;
			
			disableSticky();
		}
		
		private function onStageKeyDown(p_event:KeyboardEvent):void {
			// ignore if this component is disabled
			if (!enabled) return;
			
			if (p_event.keyCode == Keyboard.SHIFT) {
				_isShiftPressed = true;
				_object.alpha = _stickyAlpha;
				_thumbnail.alpha = _originalAlpha;
				_thumbnail.addEventListener(MouseEvent.MOUSE_DOWN, onThumbnailMouseDown);
			}
		}
		
		private function onStageKeyUp(p_event:KeyboardEvent):void {
			// ignore if this component is disabled
			if (!enabled) return;
			
			if (p_event.keyCode == Keyboard.SHIFT) {
				_isShiftPressed = false;
				_thumbnail.alpha = _stickyAlpha;
				_object.alpha = _originalAlpha;
				_thumbnail.removeEventListener(MouseEvent.MOUSE_DOWN, onThumbnailMouseDown);
			}
		}
		
		private function onObjectUpdate(p_event:Event):void {
			// ignore if this component is disabled
			if (!enabled) return;
			
			// skip if already following mouse
			if (!_isFollowMouse) return;
			
			// determine the thumbnail angle & offset coordinates
			var rad:Number = _stickyRadius + _radialLayerIndex * _stickyRadiusIncrement;
			var circ:Number = Math.PI * rad * 2; // thumbnail radial circumference
			var objectNum:Number = _layers[_radialLayerIndex].length;
			var gap:Number = circ / objectNum; // radial gap between each thumbnail
			var angle:Number = (_stickyIndexInLayer * gap / circ) * 360 + 180; // thumbnail radial angle
			angle = angle % 360;
			
			// modify the coordinate offset as per radial angle
			_xOffset = rad * -Math.sin(angle*DEG_TO_RAD);
			_yOffset = rad * Math.cos(angle*DEG_TO_RAD);
			updatePosition();
		}
	}
}